Skip to content

WIP - #137

Draft
benh wants to merge 19 commits into
riley/reboot-inspectfrom
caller-static-analysis-3
Draft

WIP#137
benh wants to merge 19 commits into
riley/reboot-inspectfrom
caller-static-analysis-3

Conversation

@benh

@benh benh commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

No description provided.

rileysdev and others added 10 commits August 13, 2026 22:14
mypy 1.18 rejects `if x is Any:` as `comparison-overlap` when `x` is
declared as a union of concrete types. The comparison is correct at
runtime: `get_args` on an annotation such as `dict[str, Any]` hands
back the `typing.Any` object itself, so the converters meet it as a
value. Only the declared parameter types disagree, since they do not
mention `Any`.

Route the comparison through `is_annotation_any`, whose parameter is
typed `object` — the honest domain of what typing introspection
returns. The converters' declared unions stay as they are, and the
runtime behavior is identical: the same `is` comparison, one call
deeper.

Until now this error failed the build of every target whose closure
reaches `reboot/api.py` under a fresh mypy run, which is how it was
found: Bazel's remote cache had been serving stale mypy results, so
CI never re-ran mypy over these files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A `description=` was only set on `McpMethodOptions.description`, so a
Reader, Writer, Transaction or Workflow that was not also an MCP tool
didn't have it. It is now set on `MethodOptions.description` for
every method, and MCP tool and resource descriptions read from there.

`McpMethodOptions.description` is deprecated but still read as a
fallback, so protos that already set it keep their descriptions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first of several changes, split so that each can be reviewed on
its own and so the dashboard can be tried out while the rest is
written. It is not a documented feature yet: nothing opens a page by
itself, so seeing one means passing `--open-dashboard` or visiting
the URL.

`rbt dev run` now starts a second Reboot application alongside the
developer's, with its own state store, holding what the dashboard
needs. The companion watches the developer's `api/` directory and
records what those files declare, so the dashboard can describe an
application. It also serves the page itself.

The page reads that schema reactively and renders one section per
state type: its fields, and each method's kind, whether it constructs,
whether it is reachable over MCP, its signature and the errors it
raises. Dashboard state, such as which detail views are open and which
are closed, is saved in Reboot state, so it survives a hot reload and
an `rbt dev run` restart.

Auto-open is complete but off. `_AUTO_OPEN_DASHBOARD` is False, so
only `--open-dashboard` opens a page. We don't reopen the dashboard if
the developer already has it open, and we use the `Presence` library
to determine whether they do. Note that presence does not drain
through a DevPod workstation's port forward, which is filed
separately.

This will eventually supersede the inspect dashboard at `/__/inspect`,
which lists state instances and their values. It does not replace it
yet and both exist meanwhile: this describes an application's API, its
state types, their fields and their methods, and cannot yet show the
data behind them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A method can say what it does, but a state type is the sum of its
state and its methods, and its name alone does not say what it is
for. `Type` now takes a description, which the dashboard shows
beside the state type's name and file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The manylinux images build every CPython with `--disable-shared` and
delete even the static `libpython` archives. The `reboot-dev-reboot`
genrule links `reboot_native.node` with `-lpython3.10`, a flag
emitted by `python3.10-config --ldflags --embed`. That link has never
been able to succeed inside these images.

CI stayed green only while Bazel's remote cache served the genrule's
outputs. The first cache miss made every platform fail
deterministically. On x86_64 that miss came from a runner hardware
swap: it changed the `lscpu` portion of `the_environment.txt`, and
with it the whole cache scope.

Point `python`/`python3` at a python-build-standalone CPython 3.10,
which ships `libpython3.10.so`. It is the same build
`reboot/nodejs/prepare_environment.sh` downloads. `pip`/`pip3` stay
on the manylinux interpreter, whose layout `auditwheel` and the
wheel builds expect. `python3` and `pip` therefore deliberately name
different installations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`rbt dashboard` needed `--api-directory`, naming a directory the
`.rbtrc` already names for `rbt generate`. Two places to say the same
thing is two places to change it, and nothing tells you when only one
of them moves -- the dashboard just watches a directory the rest of
the tooling has stopped using.

So it reads what `rbt generate` was told instead, through a new
`ArgumentParser.dot_rc_arguments`, which returns what the `.rbtrc`
gives any subcommand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
Work that waits on nothing -- parsing, hashing, encoding -- never
gives the event loop a chance of its own, so a servicer doing it over
a collection holds its process for as long as the whole collection
takes, and everything else it serves waits that long.

`concurrently` is the wrong tool, because there is nothing to overlap.
Measured over twelve parses of a 45KB file, it left the loop unable to
answer for 24ms at a stretch -- 15ms even limited to one at a time,
since its tasks are scheduled together and the loop drains several
before looking at anything else -- and cost 30% more wall-clock in
task machinery.

An `asyncio.sleep(0)` in the loop measures best, at 6ms, but invites
the question of why it is there and not somewhere else. This answers
it: the yield falls out of how the work was grouped, which is a
decision the caller has to make anyway, and the collection bounds it
the way `concurrently`'s does.

It takes elements rather than awaitables, because nothing is being
run: the work stays in the caller's body, where it can go on mutating
whatever it likes. Note an `async for` alone will not do -- `await` on
something that resolves without suspending never reaches the event
loop at all, which is why the yield has to live in here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
The API files say which state types exist. They say nothing about
which file implements one, and the name does not say either --
`servicers.py` may implement several state types while being named
after none of them. What does say is the application:

    Application(servicers=[AccountServicer, BankServicer, ...])

so this reads the entry point, resolves each registered servicer back
to the file defining it, and asks that class what it services.

Read rather than imported. Importing an application means having its
generated code, its dependencies and its `sys.path`, and the dashboard
is meant to work before any of that exists -- the same reason the API
files are read the way they are.

Driven by the API rather than by the filesystem: the API is what says
which state types there are to look for, so a state type appearing or
disappearing is what sets this going. `until_changes` suspends the
workflow in between, so it wakes when the declarations move rather
than on a timer.

Recorded one state per state type, so that working out one state
type's implementation neither waits on nor overwrites another's. A
state type the application registers no servicer for is recorded as
such rather than left looking unanalyzed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
The walk parsed every file the application reaches on every save, and
an edit changes one of them. It now records what each file was found
to hold along with a digest of the bytes it held, and parses one
again only when those bytes differ.

A digest and not `st_mtime_ns`, which is only as fine as the kernel's
coarse clock: measured here, 163 of 200 consecutive rewrites of a
file shared an mtime, so a save landing in the same tick as a read
would have left that file looking untouched for good. Reading and
hashing 50 files costs 1.6ms against 74ms to parse them, so asking
exactly is still nearly all of the saving.

Reachability is still worked out from the application every time, but
over what is already held rather than by parsing: a file that stops
being imported drops out however recently it changed, and one that
starts being imported is parsed for the first time. A file that will
not parse is left unrecorded, so it is tried again on the next save.

`File` is where the analysis will attach: it says what a file holds,
and asking whether that is still true is the question a hash of each
method will answer one level finer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
Scaffolding for analyzing them: what a servicer is made of, and a
digest of each method that says whether analyzing it again would say
anything new. Nothing is analyzed yet -- a `Method` is a name and a
digest, and what it calls is the field that follows.

The digest is over `ast.dump` without attributes, so it is of what
the method says rather than how it is laid out: reformatting it,
writing a comment in it, or pushing it down the file with an edit
above leave it alone. That is what will keep an application of a
thousand state types from re-analyzing everything on every save,
one level finer than the file digest already does for parsing.

Methods are recorded in the order they are written, which is the
order somebody reading the file meets them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
@benh
benh force-pushed the caller-static-analysis-3 branch 2 times, most recently from 116c2fd to 0b2ace5 Compare August 19, 2026 22:37
benh and others added 5 commits August 19, 2026 22:49
Which state type a servicer services takes type information: the
name in front of `.Servicer` may be spelled any way an import can
bind a name. So any class extending a dotted name ending in
`.Servicer` is a servicer, recorded under the name the developer
wrote, and type information will later replace that name with the
fully qualified state type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
An iteration now carries a `Files` value through everything it does,
immutable, like the `Analysis` that carries one file through it:
`known` is what the previous iteration analyzed, `parsed` what this
one has parsed and not yet analyzed, `analyzed` what it has
finished, and `pending` the frontier: every file reached, whose
imports are not yet followed, entering once and leaving once.

A file depends on the file behind every one of its imports: those
are the files that can change what this one means. For now every
import is taken as used, since tools like `ruff` keep unused
imports out of real code; narrowing to the imports whose names are
used can come later if this proves too eager. Each `File` records
its dependencies by the digest each had when it was read, and a
known file is kept only while its own digest and every dependency's
still match; otherwise it is parsed and analyzed again. A digest
read once is recorded, so nobody reads the same bytes twice in one
iteration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
A relative import is resolved to a path where it is collected,
since that is the one place the importing file's own directory is
known. From there it is followed to its file like any other module,
spelled as a path, with `os.sep` telling the two spellings apart,
and files are deduped by their absolute path since a file can now
be reached under two spellings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
`_read` and `_try_find_file_of` make OS calls, and they were made
with the event loop held, so a slow disk stalled every dashboard
request for as long as the disk took. Both now go through `aiofiles`,
which runs the call in a thread -- the way file operations are done
everywhere else in the repo -- and everything between `files()` and
the two of them becomes `async` to carry the `await` down.

Parsing still holds the interpreter: `ast.parse` is CPU-bound, so no
thread frees the loop from it, and `cooperatively` already bounds it
to a file at a time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
A save landing while an iteration reads produces a torn snapshot:
one file read before the save, another after. The watch is armed
before anything is read, so the save's event is already waiting when
the iteration finishes and the next one begins at once -- where a
file kept against a stale dependency digest fails its check and is
analyzed again. The digests recorded per dependency are what make
the tear detectable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
@benh
benh force-pushed the caller-static-analysis-3 branch 4 times, most recently from 2672954 to bf4cb0c Compare August 20, 2026 02:20
benh added 4 commits August 20, 2026 03:10
Which state type a class services is type information: its base may
be spelled `Account.Servicer`, assigned to another name first, or
imported from another file. So instead of reading spellings, pyright
is asked about every base every class extends. A base whose
definition leads into a file `rbt generate` wrote makes the class a
servicer, and the state type is the name the generator writes into
its classes as `__state_type_name__`, followed through the aliases
and base classes of the generated module when the definition lands
beside the name rather than on it.

A `Pyright` speaks to one `pyright-langserver` over the Language
Server Protocol on stdio, with backpressure on every write, and
answers about each file as it was last handed over. Whoever reads a
file is who hands its text over: the analysis hands each file's text
right where it reads and parses it, and it reads everything reachable
before it asks anything, so every answer is about the very snapshot
the analysis parsed, for every file at once, however the disk changes
in between. The watch takes back files that stopped existing, which
is the one change parsing never meets. The analysis records the
answers, so a file is only asked about again when it needs
reanalyzing. That is also why a file
needs reanalyzing when anything changed in the closure of its
dependencies, however many imports away: its recorded answers were
made with all of their help. And generated code, which nobody hands
over, gets a fresh server when it changes -- that only happens when
`rbt generate` ran -- along with a fresh analysis of everything,
since every recorded answer may lean on it.

A base pyright cannot yet place in generated code, most often
because `rbt generate` has not run since the state type was
declared, leaves its servicer unrecorded for now, and the new
`generated` field is what tells the dashboard to suggest running
`rbt generate`. Each servicer is recorded with the line and column
its class is written at, so a reader can be taken to it.

The generated directory is read from the `.rbtrc` arguments to
`rbt generate` (its `--python` flag), the same way the API directory
already is.

pyright comes in through npm, pinned, so that Bazel tests run a
hermetic `pyright-langserver`. The lockfile sync also picks up the
importer specifiers the 1.4.0 release left behind.
Showing how the developer's state types relate is what the dashboard
wants the implementation for: which methods call which. As a method
is analyzed, pyright is asked where the definition of everything its
body calls is. When it is in a file `rbt generate` wrote, the call
is a call on a state type: the state type is the
`__state_type_name__` of the class the definition is in, and the
method is the function the definition names.

Asking about the definition, rather than following references through
the source, makes the answer independent of how the call is spelled:
on a reference in a variable, returned by an unannotated helper,
taken out of a container, or renamed by an import, pyright resolves
them all to the same definition.

Calls are met however deep they are written, as a statement of their
own, as an argument to another call such as `asyncio.gather`, or
inside a comprehension or a nested function, and are recorded in the
order written.

What the generator defines on a state type to modify a call, `ref`,
`schedule`, `idempotently` and the rest, is a closed set and is not
recorded as calls, and neither is the generator's own underscored
plumbing. A call whose receiver pyright cannot type is dropped rather
than guessed at, until a later change records it as ambiguous.
Where in the state type's class the generator defined the called
method already says how the call was made: a method on the scheduled
variant was scheduled, on the spawned variant spawned, on the
reactive and until variants likewise; `read` and `write` are a
workflow's, and name no method; and a method on the state type's
class itself is a constructor, reached without a reference because it
is what makes the state to refer to. Anything else, on the reference
itself, on its idempotent variant, or on `forall`, is a plain call.

One shape defeats pyright: the generator types `schedule` by a type
variable, which pyright 1.1.413 does not solve, so a call on what
`.schedule(...)` returned resolves to nothing at all. The `schedule`
itself is typed ordinarily though, and its definition says which
state type is being scheduled; the method's name is written right
there in the call, and is checked against what the state type
defines. So when a call resolves to nothing and is made on a
modifier, the modifier is asked instead.
A method's body can hand its context to something the analysis
cannot see into: a helper function, a method of its own servicer, or
a call on a receiver pyright cannot type. Only whoever has the
context can reach other state types, so these are exactly the places
calls may hide. Each is recorded as ambiguous, with the called name
as the developer wrote it, so that what the analysis does not know is
visible rather than merely absent.

The context parameter is the one the generator puts first after
`self`, and handing it over is handing that name as an argument or a
keyword argument. A call that already resolved to a state type's
method is not ambiguous, however many contexts it hands over: what it
reaches is known.
@benh
benh force-pushed the caller-static-analysis-3 branch from bf4cb0c to b731827 Compare August 20, 2026 03:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants